結論先說:分子分母定義得再漂亮,沒量出來、沒告警規則接住,也只是一份文件;今天把上篇的規格接上 counter、window、error budget 與 burn-rate alert,變成值班工程師真正會看見的訊號。
上篇(Day 14 上)拆開 SLI、SLO、SLA 的責任邊界,並為 /ask 定義了 event contract:哪些欄位進 metrics、哪些進 logs,以及一份可測試的 good/bad classifier。下篇接著把這份規格落地成可查詢、可告警的東西。
在 Day 2 的 Lab 中,應讓應用程式只在 request 已經有最後結果時增加 counter。
概念上,輸出的 metric 可以長成這樣:
ask_sli_events_total{
route="/ask",
sli_definition_version="v1",
sli_eligible="true",
sli_result="good"
} 482
失敗事件則使用同一個 metric family 的有限 label 值:
ask_sli_events_total{
route="/ask",
sli_definition_version="v1",
sli_eligible="true",
sli_result="bad"
} 6
使用同一個 counter 的好處,是分子與分母來自同一批 completion event。
Google SRE 對 SLI 的核心提醒也相同:先定義使用者可見的好與壞,再把它可靠地量測。不要把 dashboard 中看似相關的兩個 metric 臨時相除,期待它們剛好涵蓋相同流量。Google SRE:Service Level Objectives
在 Prometheus 中,counter 需要用 rate() 或 increase() 看一段時間的增量;直接相減會在 process restart 後得到誤導結果。官方文件也以 rate(counter[5m]) 示範這個基本模式。Prometheus:Understanding metric types
原因值得展開講清楚,而不是背一句「不要直接相減」。Prometheus 的 counter 只會單調遞增,process 重啟時會歸零重新累加——這是設計上刻意的行為,讓每次抓取到的原始數字本身沒有直接意義,只有「跨時間的差值」才有意義。假設 ask_sli_events_total{sli_result="good"} 在某次抓取時是 1000,deployment 重啟後歸零,五分鐘後又累積到 40。若直接拿最新值減最舊值(40 - 1000 = -960),會得到一個負數,完全誤導判斷;increase() 與 rate() 內建了偵測 counter reset 的邏輯,遇到數值往回掉就視為一次重置,改用「重置後的累積量」接續計算,因此在 deploy 頻繁的服務上,這不是選配的最佳實務,而是避免每次上版就讓 SLI 曲線出現離譜負值或尖峰的必要條件。increase() 回傳的是視窗內的估計總增量(rate() × 視窗秒數),適合直接拿來當分子分母相除;rate() 回傳的是每秒平均速率,適合畫成隨時間變化的曲線。兩者用同一份底層邏輯,差別只在於你要「一段時間的總量」還是「當下的速度」。
上一段特別把 increase() 的結果稱為「估計總增量」,這不是用詞隨便,而是 Prometheus 的實際行為:increase() 底層先算 rate() 再乘上視窗長度換算回總量,但 scrape 是間隔性的(常見每 15 或 30 秒一次),視窗邊界很少剛好落在抓取點上,因此結果是線性內插外推,不是精確加總。
這個機制多數情況誤差極小;但視窗長度接近或小於抓取間隔(例如對 5 秒視窗做 increase())、或counter 剛好在視窗邊界附近重啟時,估計值可能明顯偏離實際事件數——這也是為什麼 burn-rate alert 通常選 5 分鐘以上的短視窗。日常 dashboard 可忽略這些誤差;但 incident review 若需要精確到個位數的分子分母,最終仍要回頭核對 completion log,而不是只信任 PromQL 算出的浮點數——這正是第 ③ 段「Prometheus 給趨勢、logs 給鑑識」的原因之一。
若你的 metric 已經如上輸出,近一小時 availability 的概念查詢可以是:
sum(
increase(ask_sli_events_total{
route="/ask",
sli_definition_version="v1",
sli_eligible="true",
sli_result="good"
}[1h])
)
/
sum(
increase(ask_sli_events_total{
route="/ask",
sli_definition_version="v1",
sli_eligible="true"
}[1h])
)
這條 query 有一個重要限制。
當分母是零時,它沒有可解釋的 availability。
午夜沒有 /ask 流量,不代表「100% 成功」,也不代表「0% 成功」。它只代表這個 window 沒有 request-based 樣本。
把 zero denominator 顯示為 N/A,並另外監控是否長期完全沒有流量,通常比把它補成 1 更誠實。
若只想做近期 dashboard,rate() 可讓結果隨時間連續:
sum(
rate(ask_sli_events_total{
route="/ask",
sli_definition_version="v1",
sli_eligible="true",
sli_result="good"
}[5m])
)
/
sum(
rate(ask_sli_events_total{
route="/ask",
sli_definition_version="v1",
sli_eligible="true"
}[5m])
)
短 window 適合發現剛開始的問題,卻不應拿來宣告 28 天 SLO 是否達標。
這正是 SLO window 存在的原因。
Day 2 的 Lab 用 uvicorn 起單一個 API process,但真正的生產環境幾乎不會只有一個 replica。這裡有一個容易讓新手困惑的地方:ask_sli_events_total 這個 counter 是每個 process 各自獨立累加的,process A 處理了 300 筆請求,它本地的 counter 就是 300;process B 處理了 180 筆,它的 counter 是 180。Prometheus 抓取時,會把每個 replica 各自的數值當成獨立的 time series 存起來,並不會在寫入階段就先加總成一個全域數字。
這也是為什麼前面所有 PromQL 範例都在最外層包了一個 sum(...)——它負責把所有 replica 各自累積的 increase() 結果加總成一個代表整個服務的視角。如果漏了這層 sum(),查詢結果會變成「每個 replica 各自的 availability」,而不是「整個服務的 availability」;在 Grafana 上通常會看到同一條指標線分裂成好幾條、各自代表一個 pod,而不是預期中的一條彙總曲線。更糟的情況是,如果團隊誤以為某個 replica 的曲線就代表整體,可能會在某個 replica 剛好流量特別低、數字特別好看時得出「系統很健康」的錯誤結論,卻沒注意到另一個 replica 正在大量出錯。
# 錯誤示範:少了 sum(),得到每個 pod 各自的 availability,不是整體
increase(ask_sli_events_total{sli_result="good"}[1h])
/
increase(ask_sli_events_total{sli_eligible="true"}[1h])
# 正確:先分別加總分子、分母,兩個「服務層級的總數」再相除
sum(increase(ask_sli_events_total{sli_result="good"}[1h]))
/
sum(increase(ask_sli_events_total{sli_eligible="true"}[1h]))
這個細節值得提早在 Day 14 就講清楚,因為 Day 2 建立的 Lab 目前是單一 replica,這個問題暫時不會出現;但一旦服務規模成長到需要水平擴展(例如接下來系列會談到的容量規劃),少了 sum() 的舊 dashboard 會突然開始顯示一堆分裂的曲線,而排查這個問題所花的時間,往往比一開始就寫對查詢語法要長得多。
前面幾段出現的 PromQL——sum(increase(...)) / sum(increase(...))——一旦要同時用在 Grafana dashboard、burn-rate alert 規則、以及事後 incident review 的查詢裡,很容易演變成同一段查詢邏輯被複製貼上三、四次,各自維護。這不只是重複勞動的問題,而是一個更隱蔽的風險:如果哪天有人要調整 label matcher(例如把 sli_definition_version="v1" 改成 "v2"),複製出去的三、四份查詢很可能不會同步更新,變成「dashboard 顯示 v1 的比例,alert 卻還在用 v2 的定義判斷要不要 page」——這正是第 ⑤ 段「資料從哪裡來」那一題要求「同一件事不該在兩張 dashboard 有兩個答案」的具體翻版,只是這次錯誤發生在查詢定義本身,而不是資料來源。
Prometheus 的 recording rule 正是為了解決這個問題而存在:它讓你把一段常用的查詢預先計算、存成一個新的 time series,之後所有地方都只需要引用這個算好的結果,而不是各自重新展開一次完整運算式。概念上,一份 recording_rules.yml 大致長這樣:
groups:
- name: ask_availability_sli
interval: 30s
rules:
- record: ask:sli_events:good_1h
expr: |
sum(increase(ask_sli_events_total{
route="/ask",
sli_definition_version="v1",
sli_eligible="true",
sli_result="good"
}[1h]))
- record: ask:sli_events:eligible_1h
expr: |
sum(increase(ask_sli_events_total{
route="/ask",
sli_definition_version="v1",
sli_eligible="true"
}[1h]))
- record: ask:availability_ratio:1h
expr: |
ask:sli_events:good_1h
/
ask:sli_events:eligible_1h
命名採用 Prometheus 官方建議的 level:metric:operations 慣例,讓任何人看到 metric 名稱就能猜到它在算什麼、用了多長視窗。dashboard、burn-rate alert、事後 review 三個地方都引用同一個 ask:availability_ratio:1h,而不是各自重新展開 sum(increase(...))。真正要調整 SLI 定義時,只需改這一份 rule 檔案,所有下游引用會自動跟著更新,不會有「有些地方改了、有些地方忘記改」的風險。
這個好處在 Day 14 討論的另一個場景下更明顯:第 ⑦ 段之後會介紹 multiwindow, multi-burn-rate alerting,同時需要 1 小時、5 分鐘、6 小時、30 分鐘、3 天、6 小時六種不同視窗長度的比例。如果每一條 alert rule 都各自重新展開完整的 sum(increase(...)) 運算式,alert 規則檔案會迅速膨脹成一堆幾乎一模一樣、只差視窗長度的重複區塊,且每個視窗都要對 Prometheus 重新計算一次原始 time series 的 increase(),在事件量大的服務上會疊加成不小的查詢負擔。用 recording rule 把每個視窗的分子分母各自算好、存成獨立 metric,alert 規則本身就只剩下一行简单的比較判斷式,可讀性與效能都比展開版本好上一截。
需要提醒的是,recording rule 本身也遵守與其他 metric 一樣的 label 紀律——它算出來的是聚合後的比例,不會、也不應該把 request_id 這類高基數欄位帶進計算結果裡。recording rule 解決的是「同一段運算式重複貼上」的維護問題,不是「該把什麼放進 label」的機制問題;兩者是本文③段與這裡各自要處理的不同層次議題,不要混為一談。
recording rule 本身也是程式碼的一種形式,一樣可以在合併前用 promtool test rules 跑單元測試,而不是部署到 Prometheus 之後才發現算錯:用一組人工構造的固定速率輸入(例如每分鐘固定新增 10 筆 good、1 筆 bad),斷言 60 分鐘後 ask:sli_events:good_1h 恰好等於 600。這不是在測 Prometheus 自己的 increase() 實作——那是 Prometheus 專案的責任——而是在確認我們自己寫的 rule 名稱與 label matcher 沒有筆誤:漏寫 sli_definition_version="v1"、或把 sli_eligible="true" 打成大小寫不符的 "True",這類低級錯誤人工讀 YAML 時很容易被眼睛自動腦補成正確,寫成一條會真的執行的測試就無所遁形。
假設產品與 owner 最後同意下列示範目標:
SLI: valid /ask requests completed with a contract-valid good result within 10 seconds
SLO: 99.5% over a rolling 28-day window
這裡選「rolling 28-day」而不是「calendar month」不是隨口的格式選擇,兩者算出來的數字會不一樣。Calendar window 每個月初重新歸零,代表若某天發生了一場嚴重事故,budget 的影響會固定在那個月,隔月一號就重新開始——這在對外報告或跟法務對帳時比較直覺,日期邊界清楚。Rolling window 則是每天往前滑動固定天數(例如 28 天),代表一次事故造成的 budget 消耗會持續反映在往後每一天的計算裡,直到那次事故的資料滑出視窗為止。用 rolling window 的好處是團隊不會因為剛好碰上月初就被重置、錯覺自己「重新有滿額度可用」;缺點是同一次事故要在近一個月的每一份報表裡反覆解釋。兩者沒有哪個絕對正確,但選定後要寫進 spec,並保持一致——中途從 calendar 換成 rolling(或反過來),會讓「這個月 SLO 有沒有達標」在不同時間點得到不同答案,卻沒有任何技術面真的改變。
拿一個具體時間點走一遍會更清楚:假設某服務在 9 月 3 日發生一次嚴重事故,消耗了當月三分之二的 error budget。若選 calendar month,9 月 3 日的事故只會讓「九月」看起來不達標,10 月 1 日起每一份報表都是全新起點;若選 rolling 28 天,同一次事故會讓「近 28 天 SLO 未達標」這個結論一路成立到 10 月 1 日左右才自然消退。兩者沒有孰優孰劣,但選擇不同的 window,會讓「這段期間到底可不可靠」得出完全不同的敘事——這正是為什麼 window 類型必須寫進 spec 版本紀錄,而不是留給每次報告的人臨時決定。
error budget 不是另一個神祕指標。
它是 SLO 允許的失敗比例:
error budget = 1 - SLO
= 1 - 0.995
= 0.005
= 0.5%
如果 28 天有 200,000 筆有效 /ask,這份目標最多容忍:
200,000 × 0.005 = 1,000 bad events
這不表示團隊「有 1,000 次可以壞」。
它表示在這個明確定義下,產品與工程已共同接受的風險上限是 1,000 個不符合承諾的使用者旅程。
同樣的 0.5% 在不同流量下,操作意義完全不同。
| 28 天有效請求 | 99.5% 允許的 bad events | 解讀時要補充的事情 |
|---|---|---|
| 200 | 1 | 樣本太少,單一事件就會大幅擺動 |
| 20,000 | 100 | 可觀察趨勢,但仍要看事故集中度 |
| 200,000 | 1,000 | 可用於容量與改動風險討論 |
| 20,000,000 | 100,000 | 必須再分 user journey、region 或 tenant 觀察 |
所以不要只報「剩餘 budget 72%」。
還要報 window、有效請求數、失敗類型與是否由單一 incident 造成。
error budget 的價值建立在一個前提上:分子分母的定義本身是準確的。Google SRE Workbook 在討論 error budget policy 時特別提醒一種容易被忽略的失效模式——錯誤分類(misclassification)。這不是指團隊故意作弊,而是指某些被歸類為「不計入 error budget」的失敗,實際上已經傷害了使用者;反過來,某些對使用者毫無影響的技術性故障,卻被算進了 budget 裡。
一個具體例子:若團隊把所有 4xx 一律排除分母外,理由是「client 端問題不該算我們責任」,這規則多數情況合理——直到某次伺服器端相容性破壞性變更,讓原本合法的請求全被判成格式錯誤、回傳大量 400。此時「排除所有 4xx」會讓這次貨真價實的伺服器變更影響完全不反映在 availability 數字上:dashboard 一片綠燈,但使用者端早已全面崩潰。這正是第 ② 段情境表把「已登入使用者被系統錯誤拒絕」單獨列一行、註記「多半應進分母」的原因——籠統規則應付不了所有情境。
反過來的情況同樣常見:某個背景健康檢查因與業務邏輯無關的原因偶爾失敗,卻被計入同一個 availability counter,讓 budget 被完全不影響真實使用者的雜訊悄悄吃掉——團隊可能因此不必要地暫緩安全的 deploy,或投入資源修一個根本不存在的「可靠性問題」。
兩種失效模式的共同教訓,呼應了本文從第 ③ 段就開始強調的立場:分子分母的定義不能等到 incident 發生時才臨時決定,必須在事前、以可稽核、可重現的方式先寫清楚。error budget 這套機制的可信度,完全建立在它背後的 SLI 定義夠不夠準確之上;定義本身出錯,budget 數字再精美也只是一個看起來嚴謹的誤導。Google SRE Workbook:Error Budget Policy
28 天後發現 SLO 未達標,資訊通常已經太晚。burn rate 是「失敗速度相對於允許速度」的描述,可幫助團隊在 budget 快用完時處理,而不是等月底算總帳。
Google Cloud 在 2025 年 6 月 12 日的事件凸顯了這一點。一個 null pointer bug 在 Service Control 系統中觸發,影響全球 50+ Google Cloud 服務超過 2 小時:5 月 29 日發佈的新功能沒有特性開關保護,遇到被新政策污染的資料表時觸發 crash loop,透過「所有依賴它做配額驗證的服務」瞬間放大,讓 Spotify、Discord、Snapchat 等大型依賴方統一回傳 5xx。這不是某個服務的 SLO 失敗,而是隱藏依賴的故障透過級聯讓整個生態的 budget 在分鐘內燒完。burn rate 會沿著依賴鏈傳播;定義 SLI 時必須考慮「我的故障對上游的影響」,而非只看自己的可用性。
仍用 99.5% 這個示範目標:允許錯誤率是 0.005。
若近一小時觀察到錯誤率 0.05,則:
burn rate = observed error rate / allowed error rate
= 0.05 / 0.005
= 10
也就是以正常速度的十倍燃燒 budget。
它不自動等於 pager alert。
在低流量服務,一筆失敗就可能算出非常高的瞬間 burn rate;在高風險旅程,即使錯誤率低也可能需要立即處理。告警條件至少需要同時考慮 window、最小樣本量、影響程度與人能採取的動作。
一個適合 Lab 討論的政策表如下:
| 訊號 | 可能動作 | 不該直接推論 |
|---|---|---|
| 5 分鐘 bad ratio 上升 | 查 trace、provider error、最近部署 | 月 SLO 一定失敗 |
| 1 小時持續高 burn | 暫停高風險 deploy、啟動 incident triage | 根因已確認 |
| 28 天 SLO 低於目標 | 檢討可靠性工作與 release 節奏 | 要立刻更改 SLO 定義 |
| semantic evaluator 失敗上升 | 抽樣審查 prompt、retrieval 與 dataset | API availability 已下降 |
上面的政策表只是概念性的討論;Google SRE Workbook 在《Alerting on SLOs》一章給出了更具體、可以直接照抄的起手式參數,稱為 multiwindow, multi-burn-rate alerting。核心想法是:同一個 SLO,用不同「長視窗+短視窗」的組合搭配不同的 burn rate 門檻,同時判斷「問題有多急」和「問題是否還在發生」。以 99.9% 的 SLO 為例,官方建議的起手式參數是這樣:
| 嚴重度 | 長視窗 | 短視窗 | burn rate 門檻 | 消耗 budget 比例 |
|---|---|---|---|---|
| 需要立刻叫醒人(page) | 1 小時 | 5 分鐘 | 14.4 | 2% |
| 需要盡快處理(page,較緩) | 6 小時 | 30 分鐘 | 6 | 5% |
| 排進待辦、上班處理(ticket) | 3 天 | 6 小時 | 1 | 10% |
這張表最值得注意的不是數字本身,而是「短視窗」存在的理由。若只用長視窗判斷(只看 1 小時 burn rate 是否超過 14.4),故障修復後,這條長視窗的平均值要等上整整 1 小時才會慢慢降回門檻以下——值班的人已經修好問題,pager 卻還在響,逼他手動 silence。加入短視窗(5 分鐘)作為第二個必須同時滿足的條件,故障一停止,短視窗的錯誤率幾分鐘內就會降到門檻以下,alert 幾乎立刻停止觸發。Google SRE Workbook 給的經驗法則是「短視窗抓長視窗的 1/12」,1 小時配 5 分鐘、6 小時配 30 分鐘都符合這個比例。
把這套參數套進 Day 14 這個 Lab 假設的 99.5% SLO、28 天 window,概念上的 PromQL 骨架會長成這樣(真正上線前,budget 百分比與視窗長度都要依實際流量與 on-call 負擔重新校準,不能直接照搬 99.9% 的參數):
# page 級別:1 小時 burn rate 同時要 ≥ 14.4,且 5 分鐘 burn rate 也要 ≥ 14.4
(
sum(increase(ask_sli_events_total{sli_eligible="true", sli_result="bad"}[1h]))
/
sum(increase(ask_sli_events_total{sli_eligible="true"}[1h]))
) > (14.4 * 0.005)
and
(
sum(increase(ask_sli_events_total{sli_eligible="true", sli_result="bad"}[5m]))
/
sum(increase(ask_sli_events_total{sli_eligible="true"}[5m]))
) > (14.4 * 0.005)
0.005 是這份 Lab 示範的 error budget(1 - 0.995);換成自己服務的目標時,這個係數要跟著改。這條規則要求「長視窗、短視窗都同時超標」才觸發 page,正是為了同時滿足「確實正在發生嚴重問題」與「不是曇花一現的雜訊」兩個條件。
Google SRE 將 error budget 視為可靠性與改動速度的共同語言,而不是用來懲罰某一個團隊的 KPI。Google SRE Workbook:Implementing SLOs Google SRE Workbook:Alerting on SLOs
若 error budget 已經耗盡,合理反應可能是暫緩風險較高的 prompt、model 或 retrieval change,先處理造成 bad event 的流程。
不合理反應是把 bad 改成 good,讓 dashboard 恢復漂亮。
14.4 這個數字本身是抽象的比率,只有換算回實際請求數,才會知道 alert 真正在保護什麼。延續第 ⑦ 段「同樣 0.5% error budget 在不同流量下解讀不同」的邏輯,把它套進 page 級門檻(1 小時視窗、14.4 倍 burn rate):
| 1 小時內有效請求 | 允許的正常 bad 事件(budget 的 1/24,因為 1 小時只是 28 天的 1/672,但 burn rate 用的是速率不是總量) | 觸發 page 門檻所需的 bad 事件數(約) |
|---|---|---|
| 100 | 0.5 | 8 |
| 1,000 | 5 | 72 |
| 10,000 | 50 | 720 |
(表中數字為概念性換算,14.4 × 0.005 × 有效請求數 是觸發門檻的近似 bad 事件數,實際告警系統仍以比率運算,這裡換算成整數請求數只是為了建立直覺。)
這張表最值得注意的一行是最上面:/ask 在某個離峰時段 1 小時只有 100 筆有效請求,光是 8 筆失敗就足以觸發本應保留給「嚴重、持續」問題的 page 級告警——這是「低流量服務容易被單一事件推出瞬間高 burn rate」的具體數字版本。值班工程師半夜被吵醒時,第一個該問的不是「SLO 訂錯了嗎」,而是「這個時段的樣本數夠不夠讓 burn rate 有意義」。多數團隊會在 alert 規則裡額外加一個最小樣本量門檻(有效請求數低於某個數字時改用絕對失敗數門檻)——這是低流量服務要不要被 burn rate 公式正確服務的關鍵開關。
10 秒內完成 已經把 latency 放進 availability SLI。
這很常見,也足以回答「使用者是否在可接受時間內完成這件事」。
但它不能取代 Day 16 會處理的 latency distribution。
以下兩個服務都有 99.6% 的「10 秒內完成」:
Service A: P50 0.8s, P95 1.5s, P99 7.0s
Service B: P50 0.8s, P95 8.9s, P99 9.8s
在 availability SLI 裡它們可能都過關。
使用者感受卻很不一樣,尤其是每一次都要等待模型串流、工具呼叫或 retrieval 的互動式流程。
「把 latency 門檻寫進 availability SLI」和「latency 另外開一條獨立 SLI」不是互斥做法,而是回答不同層次問題的兩層防護。10 秒門檻寫進 availability 定義,回答的是粗略但對體驗有直接意義的二元問題:「這次請求算不算達成期待?」天生適合算 error budget、做 burn-rate alert,因為 burn rate 的數學建立在「good/bad 二元分類」上。獨立的 latency SLI(P50/P95/P99 分布)回答更細緻的問題:「使用者實際體感的等待曲線長什麼樣子?」這層資訊在容量規劃、比較 model route 效能、判斷是否收緊門檻時才是真正有用的輸入——這也是為什麼兩者都要留下,而不是二選一。
門檻本身要設在哪裡,也牽動 SLO 百分比的意義。把目標從 99.5% 收緊到 99.9%,意味著要求絕大多數請求都落在遠低於 10 秒的位置,否則光是「壓線過關」的請求就可能吃掉大半個 0.1% 的 error budget。反過來把門檻從 10 秒放寬到 15 秒,同樣的 99.5% 目標會容易達成很多,因為悄悄放棄了「10 到 15 秒之間仍感受到明顯等待」這個問題。門檻與百分比是同一個承諾的兩個旋鈕,調整其中一個而不重新檢視另一個,很容易讓 SLO 名義上不變,但實際保護到的體驗已經悄悄鬆動。
因此同一條 /ask 可以同時有:
Availability SLI
→ valid requests that reached a usable outcome within 10 seconds
Latency SLI
→ distribution of end-to-end completion latency for eligible requests
Semantic-quality indicator
→ sampled evaluator result; not inferred from HTTP success
三者能共用 request_id 與 trace context,卻不該被壓成一個「AI 健康分數」。
容易被忽略的一點是:latency SLI 的分母,不見得等於 availability SLI 的分母。假設一筆 /ask 請求在第 3 秒就因為 provider timeout 而失敗,它在 availability SLI 裡是不折不扣的 bad event;但它有沒有資格出現在 latency 分布裡,要先想清楚。把所有失敗請求的「失敗前經過的時間」都塞進同一組 latency histogram,會讓分布形狀被大量短命的失敗請求拉向左邊,看起來像是「系統普遍很快」,但那其實是「壞掉得很快」,不是「服務得很快」。
比較乾淨的做法,是把 latency histogram 限定在成功完成的請求範圍內量測,失敗請求另外用 availability SLI 的分子分母處理。用 Python 的 prometheus-client 大致的宣告方式會是這樣:
from prometheus_client import Histogram
ASK_LATENCY_SECONDS = Histogram(
"ask_completion_latency_seconds",
"End-to-end latency for successfully completed /ask requests",
["route"],
buckets=(0.1, 0.25, 0.5, 1, 2, 5, 8, 10, 15, 30),
)
# 只在 workflow 真正產出可用結果時才 observe,
# provider timeout、invalid contract 這類失敗事件不進這個 histogram
if is_good_event(event):
ASK_LATENCY_SECONDS.labels(route="/ask").observe(event.latency_ms / 1000)
bucket 邊界刻意不是均勻切分,而是在「使用者關鍵決策點」附近變密——1 秒到 10 秒之間切得比較細,因為對一個互動式問答服務而言,這段區間正是使用者從「感覺流暢」滑向「開始不耐煩」的地帶;超過 10 秒之後,使用者體驗已經定調為「慢」,bucket 切得再細也不會改變任何決策,所以留給 15 秒、30 秒兩個較粗的邊界就夠。這種「在意義變化劇烈的區間加密取樣點」的設計原則,會在 Day 16 講 P50/P95/P99 與 histogram bucket 設計時再深入展開;這裡先建立一個概念:bucket 邊界不是均勻分割量表,而是使用者體驗曲線的採樣密度地圖。
有了上面的 ASK_LATENCY_SECONDS histogram,histogram_quantile() 是 PromQL 用來估計分位數的標準函式。概念上的查詢長這樣:
histogram_quantile(
0.95,
sum by (le) (
rate(ask_completion_latency_seconds_bucket{route="/ask"}[5m])
)
)
這裡刻意用 sum by (le) 而不是直接對 ask_completion_latency_seconds_bucket 取 rate() 就完事,原因跟本文第 ⑥ 段「多個 replica 各自累加」是同一個道理:每個 replica 各自維護自己的 histogram bucket 計數,sum by (le) 先把所有 replica 在同一個 le(less-than-or-equal,bucket 上界)底下的計數加總,才能算出代表整個服務、而不是單一 pod 的分位數。
histogram_quantile() 的結果是估計值,不是精確值——這一點跟本文第 ⑥ 段強調 increase() 也是估計值是同一種誠實。Prometheus 的傳統 histogram 只記錄「落在每個 bucket 邊界以內的計數」,並不知道某個請求在 bucket 內部的確切數值,因此 histogram_quantile() 是在 bucket 邊界之間做線性內插。這代表分位數的估計精確度,直接受限於 bucket 邊界切得夠不夠密:如果 P95 剛好落在 5 到 8 這個跨距較大的 bucket 之間,histogram_quantile() 給出的數字可能跟真實值有明顯落差;但如果 P95 落在 1 到 2 這種切得較密的區間,估計值會準確得多。這正好回頭印證前一段「bucket 邊界是採樣密度地圖」這句話:不是隨口的比喻,而是 histogram_quantile() 底層演算法的直接後果——bucket 切得密的地方,分位數估得準;切得疏的地方,估得粗。設計 bucket 邊界時,等於是在提前決定「未來哪個延遲區間的 P95 數字比較可信」。
以下所有步驟由讀者自行在自己的 Day 14 目錄完成。
本文沒有建立檔案、安裝套件、啟動服務、執行指令或驗證結果。
這個 DIY 刻意設計成「不需要跑起任何服務」就能驗證的形式,這本身也是在示範一件事:SLI 的定義品質,理論上可以在寫出第一行 Prometheus query 之前就先被檢驗。前面九個段落講了很多「應該怎麼想」,這六個步驟則是把那些想法收斂成三個可被獨立檢查的產物——一份規格文件、一組可重跑的分類測試、一組查詢草稿——分別對應「有沒有把責任寫清楚」「規則本身是否自洽」「資料拿出來時是否可解讀」三個層次的驗證。就算你不打算真的執行任何指令,往下讀完這六步的說明,也能看懂「一個及格的 SLI 落地流程」長什麼樣子;如果你想動手做,跑起來之後應該看到的結果與可能踩的坑,也都寫在對應段落裡。
先建立 slo.md。不要先填一個看起來專業的百分比;保留沒有證據的欄位為 TBD。
# answer-api availability SLO
## User journey
An authenticated user submits a valid `/ask` request and receives a
contract-valid result or an explicit, product-approved next step.
## SLI definition
- Definition version: `ask-availability-v1`
- Data source: application completion counter `ask_sli_events_total`
- Denominator: events where `route="/ask"` and `sli_eligible="true"`
- Numerator: denominator events where `sli_result="good"`
- Good event: contract-valid `completed` or `insufficient_context` within 10 s
- Bad event: timeout, system rejection, 5xx, invalid response contract, or over-10-s completion
- Exclusions: malformed request payloads; load-test traffic only when explicitly tagged
## Objective
- Target: `TBD` after product and traffic review
- Window: `TBD`; evaluate rolling and calendar windows before choosing
- Error-budget policy: `TBD`
## Ownership and review
- Service owner: `TBD`
- Product owner: `TBD`
- Definition review date: `TBD`
- Change log: initial Lab draft; not a production or contractual commitment
留著 TBD 不是偷懶。
它在防止「文章裡的示範數字」被誤認成系統已承諾的 production 目標。
驗證什麼:對應第 ⑤ 段「五個問題」——寫完回頭檢查每個欄位是否都對得上使用者旅程、分母、分子、資料來源、誰能改規則其中之一。若某欄位仍含糊帶過(例如「分母:合理的請求」),代表那題還沒真的被回答,該回頭重寫。
常見的坑:反射動作是想替 TBD 填一個看起來專業的數字(見第 ①、⑤ 段的陷阱)。硬填只會讓下一個讀者誤以為這是已對齊產品、法務的正式承諾;留白才是誠實的訊號。
把前面的 AskEvent、is_sli_eligible() 與 is_good_event() 放進 classify.py。
再建立 fixtures.py,放入至少六種事件:正常完成、誠實資料不足、provider timeout、HTTP 200 但 contract 無效、格式錯誤請求、系統錯誤拒絕。
請替每一筆 fixture 寫一條人可讀註解:它為什麼在分母內或外?為什麼是 good 或 bad?
這一行註解是未來 review 的入口。
驗證什麼:對應第 ④ 段「文字規格若只存在會議紀錄,半年後沒人知道 label 是誰定的」。把規則寫成程式碼跑過一輪,是在檢驗它是否真的可重現——換個人執行也該得到一樣的判定。若寫 fixture 時對某情境的 good/bad 猶豫,通常代表第 1 步的 spec 還不夠精確,該回頭補 spec,而不是在 classifier 裡用註解自我說服。這也順帶驗證了 classifier 刻意不做的判斷:requires_human_review 不在 GOOD_WORKFLOW_STATUSES 裡——即使正確轉接真人,仍算 bad,因為尚未在門檻內拿到可用結果。availability SLI 只回答「是否在時間內拿到結果」,不回答「處理方式是否恰當」。
若你的 Day 14 DIY 是標準 Python 專案,可在該專案目錄自行執行:
uv run python classify.py
你應該觀察到下列結果,而不是只確認程式「沒有報錯」:
grounded_answer {'eligible': True, 'good': True}
honest_unknown {'eligible': True, 'good': True}
provider_timeout {'eligible': True, 'good': False}
http_200_but_invalid {'eligible': True, 'good': False}
malformed_payload {'eligible': False, 'good': False}
system_denied_user {'eligible': True, 'good': False}
如果你的產品不把 insufficient_context 視為可接受結果,應改 spec、fixture 與 classifier,再重新解釋為什麼。
不要只改預期輸出讓測試通過。
預期看到什麼:六筆 fixture 應一次性全數通過,且每一行都能對照到一個具體理由——這不是「跑起來不報錯就算過」的煙霧測試。若沿用既有 DIY 專案跑 scripts/validate_slo.py,應看到六筆判定結果與末尾一行通過總結。
容易誤判的陷阱:「全部通過」只代表輸出符合你事先寫好的預期值,不代表那組預期值本身正確。若最初就把某情境的判斷寫錯(例如誤以為 insufficient_context 不該算 good),錯誤的預期會被忠實驗證通過,SLI 定義卻從一開始就偏離產品真實意圖——規格測試只保證程式碼忠實實現規則,不保證規則本身是對的。
真正串回應用程式時,只匯出有限集合的 labels。
sli_eligible: true | false
sli_result: good | bad | excluded
route: /ask
sli_definition_version: v1
不要匯出:
request_id
trace_id
full_prompt
user_email
retrieved_document_id
raw_model_output
這些資料需要被保留時,請放在有存取控制、遮罩與 retention 策略的 logs 或 trace backend;不是塞進 Prometheus label。
驗證什麼:對應第 ③ 段「event contract」與「高基數 label 不是品味問題」。把清單拆成「可以進 label」與「不行進 label」,是逼自己回答「這個欄位的可能值有沒有上限」——sli_result 只有三種、有界;request_id 每筆都不同、無界。
常見的坑:為了「以後查起來方便」,把一個看似有限、實際會隨時間增長的欄位誤判為低基數。prompt_version 就是一例——早期只有兩三個值,但隨著迭代,半年後可能累積到幾十上百個。判斷依據不是它「現在」有幾個值,而是「一年後」大概有幾個值,這正是本文把它歸進 logs/traces 而非 metrics 的原因。
先分開查三件事。
# eligible events in the last hour
sum(increase(ask_sli_events_total{
route="/ask",
sli_definition_version="v1",
sli_eligible="true"
}[1h]))
# good eligible events in the last hour
sum(increase(ask_sli_events_total{
route="/ask",
sli_definition_version="v1",
sli_eligible="true",
sli_result="good"
}[1h]))
# bad eligible events by result; the label set should remain small
sum by (sli_result) (
increase(ask_sli_events_total{
route="/ask",
sli_definition_version="v1",
sli_eligible="true"
}[1h])
)
先看 raw counts,再看 ratio。
若分母為零,確認你的圖表呈現 N/A 或清楚說明「沒有樣本」,而不是把安靜的夜晚畫成完美可用性。
驗證什麼:對應第 ⑥ 段「counter 只會單調遞增」與「zero denominator 不代表 100% 可用」。這個 Lab 尚未接上流量,執行這三條查詢時分母大概率是零,正好是觀察機會:確認 Grafana panel 在分母為零時是顯示危險的平 0%、更危險的平 100%,還是正確的 N/A 或不畫線——多數工具的預設並不會自動留白,值得在真正串接流量前先確認,而不是等某個離峰時段被主管指著一條滿分線追問「這是真的嗎」。
不需要立刻真的製造故障。
先在 README 或 runbook 寫下預期:
Scenario: provider timeout for valid /ask requests
Expected metric: eligible count increases; bad count increases
Expected trace: provider span ends with timeout or error status
Expected log: same request_id has timeout classification and deployment version
Expected user result: clear retryable or unavailable state, not an empty 200 response
Expected SLO effect: availability ratio declines for the affected window
這份假設讓你日後做 Day 3 式 fault injection 時,知道要驗證哪一條鏈,而不是只截一張 Grafana 圖。
驗證什麼:把 Day 3 的 fault injection 手法與今天定義的 SLI 語言正式接起來。寫下五行預期,是逼自己在打壞系統之前先用分子分母語言講清楚「壞掉了會怎樣」——若連紙上談兵都講不清楚,實際跑故障時多半只會看到數字變動、說不出因果。
為何不要求現在執行:Day 14 只求先把量測規則定義清楚,量出真實 baseline 是 Day 15、16 的事。SLI 定義還沒經過 review 就急著做 fault injection,容易把「classifier 有 bug」和「服務真的故障」混在一起。先寫假設、留到規則穩定後再執行,是刻意的順序安排,不是拖延。
前六步都是可以獨自完成的技術工作。第七步刻意換一種形式:把 slo.md 拿給一個沒有參與撰寫的人(同事、甚至只是把自己一週後的視角當作「另一個人」),請對方不看程式碼、只憑 spec 文件回答三個問題:
1. 這條 SLI 在量測哪一個使用者旅程?能不能用自己的話重述一次?
2. 「排除條件」欄位裡的每一項,你能不能想出至少一個
「規則沒說清楚該怎麼分類」的邊界案例?
3. 如果這個月數字不達標,你知道該去找誰、看哪份資料嗎?
如果對方在第 1 題卡住、只能複述文件裡的句子卻講不出自己的理解,通常代表 spec 寫得還是太貼近程式碼術語,沒有真正轉譯成產品語言。如果對方在第 2 題輕易就能想出 spec 沒涵蓋到的邊界情境(例如「如果使用者用 API Key 而非登入 session 呼叫,算不算 valid_request?」),這代表 fixture 集合還需要再補幾筆。如果對方連第 3 題「該找誰」都答不出來,代表 spec 裡「誰能改規則」那一欄寫得不夠具體,只留了團隊名稱而沒有實際的 owner。
驗證什麼:這是唯一「不能自己一個人完成」的步驟——第 ① 段就強調 SLI/SLO/SLA 涉及多方責任,一份只有作者自己看得懂的 spec,內部邏輯再自洽也還沒達成「可審查」的門檻。找人讀一次,是低成本地提早暴露這份文件離真正對齊產品、法務還有多遠。
前六步的重點是規則本身要正確,這個選用步驟是把「規則正確」再往前推一步:確認同一份規則寫出來只有一個版本,不會在 dashboard、alert、事後 review 三個地方各自長出稍有出入的抄本。即使你的 Day 14 DIY 專案還沒有真的連上 Prometheus,也可以先把第 ⑥ 段介紹過的 recording rule 骨架寫成一個檔案草稿:
# Day14/DIY/observability/recording_rules.yml(草稿,尚未連上真實 Prometheus)
groups:
- name: ask_availability_sli
rules:
- record: ask:sli_events:good_1h
expr: |
sum(increase(ask_sli_events_total{
route="/ask", sli_definition_version="v1",
sli_eligible="true", sli_result="good"
}[1h]))
- record: ask:sli_events:eligible_1h
expr: |
sum(increase(ask_sli_events_total{
route="/ask", sli_definition_version="v1",
sli_eligible="true"
}[1h]))
驗證什麼:把第 ⑥ 段「同一段查詢不要抄三次」的原則落成可 code review 的 YAML,而不是停留在建議。回頭檢查第 5 步的三條 ad-hoc PromQL 能否直接用這兩個 record 名稱重新表達——若某條用了不同的 label matcher,這是提早發現「查詢定義悄悄分岔」的訊號,比等 alert 跟 dashboard 數字對不上才發現便宜得多。
注意事項:這份 YAML 只是草稿,要真正生效需在 Prometheus 設定裡用 rule_files: 指向它並 reload;本 Lab 尚未走到那一步。先把骨架與命名慣例寫對,是為未來接上流量做準備。
request_id 這類高基數資料放在 trace 或 log,而非 metric label。/health 回 200 只能證明某個 process 還活著。
若 provider、retrieval 或 validator 已經失效,使用者旅程仍可能全面失敗。
health check 應保留給 deployment 與流量路由;使用者 SLI 則應由使用者可見的 completion event 量測。
兩者的區別直式寫出來會更清楚:
System health
(process 活著、能回應探測)
≠
User-facing availability
(使用者旅程真的走到可用結果)
這個落差不只是「健康檢查太粗」,健康檢查與 SLI 在設計上本來就回答不同的問題。多數健康檢查是二元判斷:process 活著回 200,process 掛了回 5xx 或直接不回應;而且常見的告警規則還要求「連續幾次探測都失敗」才觸發,目的是避免單次網路抖動就誤判。這個設計對「偵測全面停機」很有效,卻對「部分退化」完全視而不見:如果服務只有 1%~2% 的請求慢到逾時或回傳不合格的 response,其餘探測仍然正常,健康檢查會一路綠燈,直到退化擴大到影響絕大多數請求才會被發現。
Honeycomb 團隊分享過促使他們認真投入 SLO 的一次事件正是這種情境:一次只影響約 1%~2% 請求的部分退化(brown-out),傳統健康檢查完全沒有反應,因為監控邏輯要求「連續兩次探測失敗」才算故障,而這次的失敗是間歇性、分散的。團隊事後描述:「our SLO immediately started burning and we realized, relatively quickly, that our users were being affected」——以 good/bad event 比例為基礎的 SLO burn,幾乎即時反映了使用者受影響的程度。他們形容這是真正開始認真投入 SLO 的轉捩點,因為這套機制幫助他們看見「使用者體驗中的長尾,而不只是多數人的體驗」——恰好呼應本文第 ② 段拆出來的多條使用者旅程:若高風險旅程只占整體流量一小部分,用「多數使用者的體驗」代表整個系統,正是會把長尾裡的真實傷害蓋過去。Honeycomb:Actionable SLOs Based on What Matters Most
即使一切定義都做對了,還有一種誤用發生在解讀數字的最後一步:把「這個月 availability 達到 99.6%,高於 99.5% 目標」直接等同於「系統這個月沒有問題」。這個推論忽略了三件本文一路強調的事情。第一,availability SLI 通常只涵蓋單一旅程(例如一般政策問答),如果高風險轉真人旅程(前面第 ② 段的旅程 C)另外有一條沒有被拿出來看的 SLI,兩者達標與否是分開的事,不能用其中一個代表全部。第二,達標的 99.6% 底下可能藏著集中在特定時段、特定 tenant、或特定 prompt 版本的失敗——第 ⑦ 段已經提醒「不要只報剩餘 budget 比例」,同樣的道理也適用於「達標」這個結論本身:一個月平均達標,不代表這個月裡沒有任何一小時 burn rate 衝到 10 倍以上,只是那段時間夠短,沒能拖垮月度平均。第三,SLI 定義本身有沒有涵蓋到位使用者真正在意的失敗模式,是一個持續要重新檢視的問題,不是定義一次就永遠正確——這正是第 ①、⑤ 段反覆強調版本與 review 機制的原因。
「達標」是一個必要但不充分的健康訊號。它值得被慶祝,但慶祝的方式應該是「這個月我們對使用者的承諾兌現了」,而不是「這個月系統完全沒有值得關注的問題」——後者需要搭配 latency 分布、semantic evaluation、以及對達標數字背後的分布做進一步檢視才能下結論。
「429 都是使用者的問題」與「4xx 都不進分母」都是過早結論。
請先拆出被文件化 quota 限制、惡意請求、認證 provider 故障與權限規則 bug,再由產品與 owner 決定各自是否屬於承諾。
可稽核的排除是定義。
incident 後臨時排除剛好造成數字變差的流量,是改寫歷史。
availability SLI 回答「使用者是否在承諾時間內得到可用結果」。
它不會證明回答中的政策事實正確,也不會量化 citation 是否支持答案。
把 semantic evaluation、safety refusal、cost 與 latency distribution 分成不同訊號,才能在其中一項變差時知道要改哪一段 workflow。
第 ⑧ 段介紹 multiwindow, multi-burn-rate alerting 時提到,只用一個長視窗(例如固定看 1 小時)判斷 burn rate,會在故障排除後讓 pager 持續響上很長一段時間,逼值班工程師手動 silence——這是最直接的副作用。但還有一個更隱蔽的問題:只用單一短視窗(例如只看 5 分鐘)反過來會對雜訊極度敏感,尤其在流量本來就不大的服務上,一次偶發的批次失敗(例如某個下游依賴短暫抖動 30 秒)就足以讓 5 分鐘視窗的錯誤率瞬間衝破任何合理的門檻,觸發一次其實不需要叫醒任何人的 page。
這兩種偏誤方向相反,卻是同一個根因:用單一視窗長度去同時回答「問題有多急」和「問題是否仍在發生」這兩個不同的問題。長視窗適合判斷「這是不是一次持續、有意義的退化」,短視窗適合判斷「現在這一刻,問題是否還在燒 budget」;只用其中一個視窗,等於放棄了另一半的判斷力。這也是為什麼值得投入時間把 burn-rate alert 至少做成兩層——即使一開始只能先做最粗略的版本,也比完全不用 burn rate、只在月底看一次總體 SLO 達標與否要有意義得多。
在合約裡寫出 99.9% 之前,至少要能以同一份資料、同一套 exclusion 規則、同一個 window 算出相同結果。
若不同人從不同 dashboard 得到不同數字,現在需要的是 SLI definition review,不是更高的 SLA。
最後一種常見誤用發生在方向相反的地方:把對外承諾的 SLA 數字直接拿來當內部團隊的告警門檻——「反正 SLA 是 99.9%,就在這條線設告警」,聽起來謹慎,但 SLA 與 SLO 的目的完全不同,混用會製造兩種相反的風險。
若 SLO 目標與 SLA 承諾設成同一個數字,團隊會在「真正違反合約」那一刻才收到第一個告警,值班工程師能做的只剩事後補救。第 ① 段已強調 SLO 必須比 SLA 更嚴格,正是為了在合約被打破前留出反應空間——例如 SLA 承諾 99.9%、內部 SLO 訂 99.95%,這個差距就是「早期預警帶」。
反過來,若把 SLA 數字當成日常對外宣傳或口頭承諾的內部指標,業務同仁看到 dashboard 上寫「SLA: 99.9%」,很容易誤以為這已是簽進合約、具法律效力的數字,拿去對客戶做出未經法務審過的承諾。SLA 只該存在於正式合約與技術附錄裡,內部 dashboard 顯示的永遠是 SLO,並清楚標示「這是內部目標,不是合約條款」。
第 ① 段已拆過這個機制:稀釋。這裡的具體場景通常是:主管一句「能不能給我一個數字」,工程團隊把三、四條 SLI 用流量比例加權平均,湊出「本月 AI 服務健康分數:99.2%」。這個分數一旦出現在月報裡就會取得不該擁有的權威性——旅程 C 表現明顯惡化,因流量占比極小、加權後幾乎不受影響,主管看到的仍是「99.2%,符合預期」,沒人意識到業務風險最高的旅程正在惡化,直到演變成客訴才回頭發現數字早就在下滑。
比較誠實的做法不是拒絕給一個數字,而是換一種呈現:一排各自獨立的燈號,或一張「這個月哪條 SLI 沒達標」的簡短列表,而不是把所有訊息揉成一個資訊量反而更少的百分比。
前面談了很多次「SLI 定義有版本、有 changelog」,但還有一個更基本的問題沒回答:誰有權力改這份定義?若答案是「寫程式的人自己改」,前面所有版本紀錄的討論都會流於形式——同一群人既是被量測的對象,也是量測標準的制定者,任何一次私下調整都可能只是把不想面對的數字改到不被看見。
一個比較站得住腳的做法,是把「誰能提案」跟「誰能核准」分開來看:
| 角色 | 能做的事 | 不能做的事 |
|---|---|---|
| 開發/SRE 團隊 | 提案修改 SLI 定義(例如調整門檻、新增排除條件)、附上理由與預期影響 | 未經核准直接上線新的分類邏輯 |
| 產品/業務負責人 | 核准是否符合使用者實際期待、確認排除條件不是在掩蓋真實問題 | 片面要求放寬門檻卻不留下書面理由 |
| 稽核/法遵(如涉及 SLA) | 核對 SLI 定義變更是否影響對外合約承諾 | 干預技術層面的量測實作細節 |
這張表不是要建立繁瑣的簽核流程——多數團隊規模根本不需要三個角色分開審核。重點是原則本身:修改「怎麼判斷成功」的人,不應該只有「被這個判斷結果評分」的同一群人。哪怕只是「開發者提 PR、產品負責人留言核准」這麼輕量的形式,留下「誰、什麼時候、因為什麼理由」的紀錄,就比「工程師自己覺得該改就改」站得住腳。
SLO 的工作不是替系統發獎牌,而是讓團隊知道何時該停止增加風險。今天定義的分子分母、fixture、burn rate 告警與版本紀錄,全部服務同一個目的:把「系統好不好」從一句模糊的感覺,變成任何人都能重新算出同樣結果的規格。這份規格還不是終點,只是讓後面的告警、事後檢討、容量規劃有共同語言可以對話——之後每一天遇到的告警閾值、SLO 目標、容量規劃,都建立在今天這份定義之上。定義本身站不住腳,後面疊上去的數字算得再精確,也只是精確地算錯。
Day 14 建立了 SLI、SLO、SLA 這套語言,但還沒有量出任何一個真正的數字。Day 15 要處理最基本、也最常被誤解的指標:availability 到底該怎麼算,才不會讓「系統正常運作中」跟「使用者拿到可用結果」變成兩件事。
這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.